Skip to content

signerd: durable daily-cap — reserve-before-sign + forward-only rollover + crash-budget (#108) - #125

Merged
hellno merged 3 commits into
mainfrom
hellno/provo
Jun 21, 2026
Merged

signerd: durable daily-cap — reserve-before-sign + forward-only rollover + crash-budget (#108)#125
hellno merged 3 commits into
mainfrom
hellno/provo

Conversation

@hellno

@hellno hellno commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Summary

Makes the signer daemon's daily spend cap durable (issue #108, reviewed + re-scoped to "Option A" via /autoplan). The cap (spent_today_wei) was in-memory and force-zeroed on every daemon load, so any restart — crash, OOM, app update, or a same-uid attacker crash-looping the auto-respawning daemon — silently reset the day's accounting and re-opened the within-cap drain. This persists the counter and reserves the spend before signing: a new spend.json ({chain_id, account, day, committed_wei, reserved_wei}) written with the same atomic recipe as the vault, recovered into the policy on boot. A crash between reserve and commit is counted as spent on reboot (conservative; recovery only ever tightens the cap). The UTC-day rollover is now forward-only (a backward wall-clock can't reset the window), and a non-resetting supervisor crash-budget stops respawning a crash-looping daemon instead of hot-looping at the 200ms backoff floor.

Scope was deliberately held: no nonce-keyed RPC reconciliation (that restructures the frozen broadcast path to defend a sub-second window the file-delete attacker bypasses anyway), and the security value sequences with #72 (the cap values still live in an unauthenticated policy.json). See the issue and its /autoplan review comment for the full reasoning.

Key changes: extract deckard_core::atomic_write(path, &[u8]) from Vault::write_atomic (reused for the counter); new deckard-signerd/src/spend_store.rs; reserve/commit/release wired into Daemon::execute (clean RPC rejection releases the reservation, a timeout keeps it counted); forward-only rollover; account binding at unlock; supervisor crash-budget; new reserve_failed deny tag.

Linked issue

Closes #108

Definition of Done

  • cargo fmt --all --check is clean
  • just check is green — clippy -D warnings on the default config, --features tray, and the dev-signerd-bin arm
  • cargo test --workspace is green (incl. 11 new spend_store unit tests, 2 supervisor crash-budget tests, and 2 anvil e2e durability tests)
  • No new or changed dependencies (Cargo.toml / Cargo.lock untouched — only std, and already-present serde/serde_json/alloy-primitives/anyhow)
  • No visual/UI change (signerd daemon internals only)
  • No secret is logged or Debug-printed (the counter holds only chain id, account address, day, and wei amounts — never key material)
Evidence (paste command output here)
$ cargo fmt --all --check
# (exit 0, clean)

$ just check
# (clippy -D warnings green on workspace, -p deckard-app --features tray, -p deckard-signerd --features dev-signerd-bin)

$ cargo test --workspace
# (green — see below)

Notes for reviewers

  • Security trade-off (documented): the counter file is itself same-uid-rollback-able. That residual is accepted (ADR 0004) — the durable counter's real value is honest-restart durability; the crash-loop drain is additionally killed at the mechanism by the supervisor crash-budget, and the security value completes when Spike: make policy.json tamper-evident + define a secure edit/maintenance path (not in the vault) #72 authenticates the cap values. The reframe (HIGH-security → durability) and the dropped nonce-reconciliation are explained in the issue's /autoplan comment.
  • F4 (the subtle one): a broadcast timeout keeps the reservation counted (the tx may have landed — releasing would re-open the double-spend); only a clean RPC rejection releases it.
  • fsync under the daemon mutex: the reserve/commit writes land on the lock that also serves STOP. Accepted for v1 (anvil-instant), matching the existing "lock held across broadcast" precedent; moving them off-lock via spawn_blocking is a noted fast-follow if the latency bites.
  • Deferred (out of scope, by review): nonce-keyed exact reconciliation (post-Spike: make policy.json tamper-evident + define a secure edit/maintenance path (not in the vault) #72 issue); policy.json authentication (Spike: make policy.json tamper-evident + define a secure edit/maintenance path (not in the vault) #72); the app-side UI surfacing of DaemonSupervisor::is_crashed_out() (the flag is exposed; the banner is a follow-up so this PR doesn't touch the GPUI app).
  • Test caveat: the 2 anvil e2e tests skip gracefully when anvil isn't on PATH. The conservative crash-restart guarantee (orphaned reserve counts as spent) is proven at the store level (reserved_leftover_on_reload_counts_as_spent) rather than with a process-abort fault hook.

hellno added 2 commits June 21, 2026 10:48
Pull the temp+fsync+rename+dir-sync recipe out of Vault::write_atomic into a
free fn deckard_core::atomic_write(path, &[u8]) (Vault::write_atomic now
delegates), so the signer daemon's durable spend counter (#108) reuses the
identical durability recipe instead of re-deriving it. Add a SPEND_FILE
('spend.json') const + spend_path() next to the vault/policy ones.

Prerequisite for #108.
…ver + crash-budget (#108)

The daily spend cap (spent_today_wei) was in-memory and force-zeroed on every
load, so any restart (crash, OOM, app update, or a same-uid attacker
crash-looping the auto-respawning daemon) silently reset the day's accounting
and re-opened the within-cap drain.

- New spend_store.rs: durable spend.json {chain_id, account, day, committed,
  reserved}, atomic-written; recovered into the policy on boot.
- Reserve-before-sign in execute(): reserve durably before the signature is
  released (skip value==0; reserve-write failure denies fail-closed via a new
  reserve_failed deny tag). Success commits; a clean RPC rejection releases; a
  TIMEOUT keeps it counted and marks the request terminal (status UNKNOWN may
  have landed — no retry/double-spend). A crash between reserve and commit is
  counted as spent on reboot (conservative — recovery only tightens the cap).
- Forward-only rollover: a backward wall-clock can no longer reset the window.
- Account bound at unlock (re-key starts a fresh window); missing file -> fresh,
  corrupt -> fully-spent + loud until unlock/rollover.
- Supervisor crash-budget (Option D): N crashes / M min with no sustained healthy
  run -> stop respawning + surface, instead of hot-looping at the backoff floor.

Scope held per /autoplan review: no nonce-keyed RPC reconciliation (deferred,
post-#72); the security value sequences with #72 (the cap values still live in
an unauthenticated policy.json).

Tests: 12 spend_store units, 2 crash-budget units, 2 anvil e2e (honest-restart
recovery + cap-survives-restart). Closes #108.
Captures the deferred 'move the reserve/commit fsyncs off the daemon mutex via
spawn_blocking if STOP latency bites' as a greppable code TODO (per review of
PR #125) rather than an issue — revisit only if measured latency hurts.
@hellno
hellno merged commit 04ad856 into main Jun 21, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

signerd: reserve daily-cap before signing + durable rollback-resistant accounting (C3)

1 participant